<!DOCTYPE html>
<html lang="en">
<head>
<!--
Standalone web app to convert an image file to an OpenSCAD array, for use with BOSL2 textures.
Versions 1-5: 22 April 2025 - by Alex Matulich (collaborating with ChatGPT for crop panel CSS, file loading and saving, and Gaussian blur)
Version 6: 23 April 2025 - added cropping UI
Version 7: 25 April 2025 - added contrast and threshold sliders
Version 8: 26 April 2025 - added file size estimate to output section
Version 9: 20 May 2025 - improved appearance UI, added Sobel edge detection
Version 10: 21 May 2025 - Added array_name_size value at top of output file
Version 11: 22 May 2025 - Fixed filter artifacts at image edges, added sharpening filter
Version 12: 30 May 2025 - Made filters mutually exclusive
-->
  <title>Image to OpenSCAD array, v12</title><!-- REMEMBER TO CHANGE VERSION -->
  <meta charset="UTF-8">
  <style>
    body { font-family: sans-serif; padding-left:1em; padding-right:1em;}
    h1,h2,h3,h4 { font-family: serif; }
    fieldset {
      border: 2px ridge silver;
      border-radius: 8px;
      margin-bottom: 8px;
      min-width: 300px;
    }
    legend {
      font-weight: bold;
      font-family: Serif;
      font-size: larger;
      padding: 0 6px;
    }

    input[type="range"] {
      flex: 1;
      min-width: 0;
    }
    .slider-row {
      display: flex;
      align-items: center;
    }
    .slider-label {
      width: 9ch;
    }
    .slider-container {
      flex: 1;
      display: flex;
      align-items: center;
      gap: 1ch;
      min-width: 0;
    }
    .slider-value {
      width: 4ch;
      text-align: right;
    }

    .uiContainer {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 20px;
      margin: 10px 0 10px 0;
    }
    #inputArea {
        background-color: #FFFFBB;
        border: 6px outset #DDDD99;
        padding: 1em;
    }
    #outputArea {
        background-color: #EEFFEE;
        border: 6px outset #BBDDBB;
        padding: 1em;
    }
    .canvasWrapper {
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    canvas {
      border: 1px solid #ccc;
    }
    .tooltip {
      position: relative;
      display: inline-block;
      border-bottom: 1px dotted black;
    }

    .tooltip .tooltiptext {
      visibility: hidden;
      white-space: nowrap;
      display: block;
      font-size: small;
      background-color: black;
      color: #fff;
      text-align: left;
      border-radius: 6px;
      padding: 5px;

      /* Position the tooltip */
      position: absolute;
      z-index: 1;
    }

    .tooltip:hover .tooltiptext {
      visibility: visible;
    }

    /* cropping control panel stuff */

    .crop-container {
      display: grid;
      grid-template-areas:
        "top top top"
        "left center right"
        "bottom bottom bottom";
      grid-template-columns: auto 60px auto;
      grid-template-rows: auto 60px auto;
      gap: 4px;
      padding: 2px;
      box-sizing: border-box;
      width: fit-content;
      height: fit-content;
      margin-top: 8px;
    }
    .crop-center {
      grid-area: center;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 60px;
      height: 60px;
      border: 2px dashed #ccc;
      background-color: #eee;
      font-weight: bold;
      font-size: 1rem;
      box-sizing: border-box;
    }
    .crop-control {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      font-size: 0.9rem;
    }
    .crop-control input[type="number"] {
      width: 6ch;
      padding: 2px;
      font-size: 1rem;
      text-align: right;
      border: 1px solid #ccc;
      border-radius: 3px;
    }
    .crop-top    { grid-area: top; }
    .crop-left   { grid-area: left; }
    .crop-right  { grid-area: right; }
    .crop-bottom {
      grid-area: bottom;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
    }
  </style>
</head>
<body>
<h1>Convert image to OpenSCAD array</h1>
<p>This utility accepts an image that can be displayed in your browser, and converts it to grayscale
expanded to use the maximum possible luminance range. The file types supported depend on your browser.
Alpha channel is ignored. After processing the image as desired, you may save it as an OpenSCAD array.</p>
<p>Keep the output image width small! A large size results in a huge output file when converting an image to text data.</p>
<hr>
<div id="content">
  <div class="uiContainer" id="inputArea" tabindex="0">
    <fieldset>
      <legend>Select an image</legend>
      <input type="file" id="imageInput" accept="image/*">
      <p><em>You can also paste an image (Ctrl+V) into this section from your clipboard.</em></p>
    </fieldset>
    <!-- Original image canvas -->
    <div class="canvasWrapper">
      <p id="originalSize"></p>
      <canvas id="originalCanvas" width="200"></canvas>
    </div>
  </div>

  <div class="uiContainer" id="outputArea">
    <div>
      <fieldset>
      <legend>Transformations</legend>
      <label for="resizeWidth">Rescale original width (px):</label>
      <input type="number" id="resizeWidth" size="5" min="1" max="9000" value="100"><br>
      <button id="rotateLeft">⟲ Rotate left</button>
      <button id="rotateRight">⟳ Rotate right</button><br>
      <button id="flipHorizontal">⇋ Flip horizontal</button>
      <button id="flipVertical">⇵ Flip vertical</button>

      <div class="crop-container">
        <div class="crop-control crop-top">
          <label for="crop-top">Top</label>
          <input type="number" id="cropTop" min="0" max="9999" value="0">
        </div>
        <div class="crop-control crop-left">
          <label for="crop-left">Left</label>
          <input type="number" id="cropLeft" min="0" max="9999" value="0">
        </div>
        <div class="crop-center">Crop</div>
        <div class="crop-control crop-right">
          <label for="crop-right">Right</label>
          <input type="number" id="cropRight" min="0" max="9999" value="0">
        </div>
        <div class="crop-control crop-bottom">
          <input type="number" id="cropBottom" min="0" max="9999" value="0">
          <label for="crop-bottom">Bottom</label>
        </div>
      </div>
      </fieldset>

      <fieldset>
      <legend>Appearance</legend>
      <div style="float:right; border:1px solid green; padding:4px; text-align:center; font-size:smaller;">See:<br><a href="https://en.wikipedia.org/wiki/Luma_(video)" target="_blank">Luma</a></div>
      <input type="radio" name="grayModel" value="ntsc" checked><label for "grayModel" class="tooltip"> NTSC grayscale formula
      <span class="tooltiptext">NTSC Y&prime; = 0.299R + 0.587G + 0.114B<br>Rec. 601: Average human perception of color luminance</span></label><br>
      <input type="radio" name="grayModel" value="linear"><label for="grayModel" class="tooltip"> sRGB linear luminance
      <span class="tooltiptext">sRGB Y&prime; = 0.2126R + 0.7152G + 0.0722B<br>Rec. 709: Digital HD, used by OpenSCAD surface()</span></label>

      <div style="margin-top:8px;">
        <label><input type="checkbox" id="invertBrightness"> Invert brightness</label>
      </div>
      <fieldset style="margin:8px 0;">
	  <legend style="font-size:medium;">Filter</legend>
	    <input type="radio" name="filterSelect" value="blur" checked>
        <label for="blurRadius">Gaussian blur radius (pixels):</label>
        <input type="number" id="blurRadius" size="5" min="0" max="20" value="0"><br>
		<input type="radio" name="filterSelect" value="sharp">
        <label for="sharpenRadius">Sharpen radius (pixels):
        <input type="number" id="sharpenRadius" size="5" min="0" max="20" value="0"><br>
		<input type="radio" name="filterSelect" value="edge">
        <label for="sobelRadius">Edge detect radius (pixels):
        <input type="number" id="sobelRadius" size="5" min="0" max="20" value="0">
      </fieldset>

      <div class="slider-row">
        <label for="contrast" class="slider-label tooltip">Contrast
        <span class="tooltiptext">Compress brightness above and below threshold<br>to maximum and minimum brightness.</span></label>
        <div class="slider-container">
          <input type="range" id="contrast" min="0" max="100" value="0">
          <span id="contrastValue" class="slider-value">0</span>
        </div>
      </div>
      <div class="slider-row">
        <label for="threshold" class="slider-label tooltip">Threshold
        <span class="tooltiptext">Level between black (-128) and white (127)<br>around which to adjust contrast.</span></label>
        <div class="slider-container">
          <input type="range" id="threshold" min="-128" max="127" value="0">
          <span id="thresholdValue" class="slider-value">0</span>
        </div>
      </div>
      </fieldset>

      <fieldset>
      <legend>Output</legend>
      <label><input type="checkbox" id="normalizeToUnit" checked> Normalize range to [0,1] &mdash; uses [0,255] if unset</label>
      <div style="margin-top:8px;">
        <label for="arrayName">Name of array:</label>
        <input type="text" id="arrayName" value="image_array" onkeypress="return event.charCode != 32">
        <div style="margin-top:8px;">
          <button id="downloadButton">Save as OpenSCAD array</button> &nbsp;&approx; <strong><span id="kbytes">0 bytes</span></strong>
        </div>
      </div>
      </fieldset>
    </div>
    <!-- Grayscale output image canvas -->
    <div class="canvasWrapper">
      <p id="grayscaleSize"></p>
      <div id="outcontainer">
      <canvas id="grayscaleCanvas"></canvas>
      </div>
    </div>
  </div>

</div>
<hr>

  <script>
    // get page element handles

    const imageInput = document.getElementById('imageInput');
    const downloadButton = document.getElementById('downloadButton');
    const resizeWidthInput = document.getElementById('resizeWidth');
    const originalSizeText = document.getElementById('originalSize');
    const grayscaleSizeText = document.getElementById('grayscaleSize');
    const invertBrightnessCheckbox = document.getElementById('invertBrightness');
    const normalizeToUnitCheckbox = document.getElementById('normalizeToUnit');
    const rotateLeftBtn = document.getElementById('rotateLeft');
    const rotateRightBtn = document.getElementById('rotateRight');
    const flipHorizontalBtn = document.getElementById('flipHorizontal');
    const flipVerticalBtn = document.getElementById('flipVertical');
    const cropTop = document.getElementById('cropTop');
    const cropLeft = document.getElementById('cropLeft');
    const cropRight = document.getElementById('cropRight');
    const cropBottom = document.getElementById('cropBottom');
	const filterSelect = document.getElementById('filterSelect');
    const blurRadiusInput = document.getElementById('blurRadius');
    const sobelRadiusInput = document.getElementById('sobelRadius');
    const sharpenRadiusInput = document.getElementById('sharpenRadius');
    const contrastInput = document.getElementById('contrast');
    const contrastValue = document.getElementById('contrastValue');
    const thresholdInput = document.getElementById('threshold');
    const thresholdValue = document.getElementById('thresholdValue');
    const arrayName = document.getElementById('arrayName');
    const inputArea = document.getElementById('inputArea');
    const originalCanvas = document.getElementById('originalCanvas');
    const grayscaleCanvas = document.getElementById('grayscaleCanvas');
    const kbytes = document.getElementById('kbytes');

    // other initializations

    const originalCtx = originalCanvas.getContext('2d');
    const grayscaleCtx = grayscaleCanvas.getContext('2d');

    const cropID = [ cropRight, cropTop, cropLeft, cropBottom ]; // counterclockwise from right
    let edgeID = [ 0, 1, 2 ,3 ]; // counterclockwise from right: right, top, left, bottom
    const edgeconfig = [
    // IDs of crop gadgets corresponding to image edges, from right edge counterclockwise,
    // in all combinations of rotations and flips.
    //        no flip     flipH      flipV     flipV+H
    /*  0*/ [[0,1,2,3], [2,1,0,3], [0,3,2,1], [2,3,0,1]],
    /* 90*/ [[3,0,1,2], [1,0,3,2], [3,2,1,0], [1,2,3,0]],
    /*180*/ [[2,3,0,1], [0,3,2,1], [2,1,0,3], [0,1,2,3]],
    /*270*/ [[1,2,3,0], [3,2,1,0], [1,0,3,2], [3,0,1,2]]
    ];
    let grayscaleMatrix = [];
    let currentImage = new Image();
    let rotation = 0;
    let flipH = false;
    let flipV = false;
    let origDim = { width:0, height:0 };
    let uncropDim = { width:0, height:0 };
    let cropDim = { width:0, height:0 };
    let invertBrightness = false;
    let contrast = 0.0001; // ranges from 0.0001 to 100.0001
    let threshold = 128.0/255.0; // ranges from 0. to 1.0

    // image processing functions

    function gaussianKernel1D(radius) {
      const sigma = radius > 0 ? radius / 3 : 1;
      const kernel = [];
      let sum = 0;
      for (let i = -radius; i <= radius; i++) {
        const value = Math.exp(- (i * i) / (2 * sigma * sigma));
        kernel.push(value);
        sum += value;
      }
      return kernel.map(v => v / sum);
    }

    function sobelDerivativeKernel(size) {
      const half = Math.floor(size / 2);
      const kernel = [];
      for (let i = -half; i <= half; i++) {
        kernel.push(i);
      }
      const norm = kernel.reduce((acc, val) => acc + Math.abs(val), 0) || 1;
      return kernel.map(v => v / norm);
    }

    function convolve1DHorizontal(matrix, kernel) {
      const width = matrix[0].length;
      const height = matrix.length;
      const r = Math.floor(kernel.length / 2);
      const result = [];
	  let indx, nx;
      for (let y = 0; y < height; y++) {
        result[y] = [];
        for (let x = 0; x < width; x++) {
          let sum = 0;
          for (let k = -r; k <= r; k++) {
			indx = x+k;
            nx = indx<0 ? -indx : (indx>=width ? 2*(width-1)-indx : indx); // reflect edges
            if (nx >= 0 && nx < width) {
                sum += matrix[y][nx] * kernel[k+r];
            }
          }
          result[y][x] = sum;
        }
      }
      return result;
    }

    function convolve1DVertical(matrix, kernel) {
      const width = matrix[0].length;
      const height = matrix.length;
      const r = Math.floor(kernel.length / 2);
      const result = [];
	  let indx, ny;
      for (let y = 0; y < height; y++) {
        result[y] = [];
        for (let x = 0; x < width; x++) {
          let sum = 0;
          for (let k = -r; k <= r; k++) {
		    indx = y+k;
            ny = indx<0 ? -indx : (indx >= height ? 2*(height-1)-indx : indx); // reflect edges
            if (ny >= 0 && ny < height) {
                sum += matrix[ny][x] * kernel[k+r];
            }
          }
          result[y][x] = sum;
        }
      }
      return result;
    }

    function computeEdgeMagnitude(gx, gy) {
      const height = gx.length;
      const width = gx[0].length;
      const result = [];
      for (let y = 0; y < height; y++) {
        result[y] = [];
        for (let x = 0; x < width; x++) {
          const mag = Math.sqrt(gx[y][x] ** 2 + gy[y][x] ** 2);
          result[y][x] = mag;
        }
      }
      return result;
    }

    function applyGaussianBlur(matrix, blurRadius) {
	  if (blurRadius <= 0) return matrix;
      const gKernel = gaussianKernel1D(blurRadius)
      g1 = convolve1DVertical(matrix, gKernel);
      return convolve1DHorizontal(g1, gKernel);
    }

    function applySharpen(original, radius, k=1.0) {
	  if (radius <= 0) return original;
      const height = original.length;
      const width = original[0].length;
	  blurred = applyGaussianBlur(original, radius);
      const result = [];
      for (let y = 0; y < height; y++) {
        result[y] = [];
        for (let x = 0; x < width; x++) {
          result[y][x] = original[y][x] + k * (original[y][x] - blurred[y][x]);
        }
      }
      return result;
	}

    function applySobel(matrix, radius) {
      if (radius <= 0) return matrix; // No edge detection
      const sobelSize = 2 * radius + 1;
      const dKernel = sobelDerivativeKernel(sobelSize);
      let gblur = applyGaussianBlur(matrix, radius);
      gx = convolve1DHorizontal(gblur, dKernel);
      gy = convolve1DVertical(gblur, dKernel);
      return computeEdgeMagnitude(gx, gy);
    }

    function sigmoid(z) { return 1.0 / (1+Math.exp(-z)); } // used by contrastAdj

    function contrastAdj(brightness) { // return an adjusted brightness based on contrast and threshold
      const x = brightness/255.0;
      const c = 2.0*contrast; // attempt to balance the sigmoid response to the contrast control
      const sigterm = sigmoid(-c*threshold);
      const adj = contrast>100.0 ? (x<threshold ? 0 : x>threshold ? 1 : threshold) // jump to 100% contrast at max contrast
          : (sigmoid(c*(x-threshold)) - sigterm) / (sigmoid(c*(1.0-threshold)) - sigterm);
      return adj * 255.0;
    }

    function processImage() {
      if (!currentImage.src) return;

      origDim.width = currentImage.naturalWidth;
      origDim.height = currentImage.naturalHeight;

      // display thumbnail original image
      const thumbWidth = 200;
      const thumbHeight = Math.round((origDim.height / origDim.width) * thumbWidth);
      originalCanvas.width = thumbWidth;
      originalCanvas.height = thumbHeight;
      originalCtx.clearRect(0, 0, thumbWidth, thumbHeight);
      originalCtx.drawImage(currentImage, 0, 0, thumbWidth, thumbHeight);
      originalSizeText.textContent = `Original size: ${origDim.width}×${origDim.height}`;

      // get output image dimensions
      uncropDim.width = origDim.width;
      uncropDim.height = origDim.height;
      const newWidth = parseInt(resizeWidthInput.value);
      if (!isNaN(newWidth) && newWidth > 0) {
        uncropDim.width = newWidth;
        uncropDim.height = Math.round(newWidth * origDim.height / origDim.width);
      }

      // put original image in a temporary canvas with output dimensions and get image data
      const tempCanvas = document.createElement('canvas');
      tempCanvas.width = uncropDim.width;
      tempCanvas.height = uncropDim.height;
      const tempCtx = tempCanvas.getContext('2d');
      tempCtx.drawImage(currentImage, 0, 0, uncropDim.width, uncropDim.height);
      const imgData = tempCtx.getImageData(0, 0, uncropDim.width, uncropDim.height);
      const data = imgData.data;

      // convert image data to grayscale
      const brightnessMatrix = [];
      const model = document.querySelector('input[name="grayModel"]:checked').value;
      const weights = model === 'linear' ? [0.2126, 0.7152, 0.0722] : [0.299, 0.587, 0.114];
      for (let y = 0; y < uncropDim.height; y++) {
        const row = [];
        for (let x = 0; x < uncropDim.width; x++) {
          const i = (y * uncropDim.width + x) * 4;
          const r = data[i];
          const g = data[i + 1];
          const b = data[i + 2];
          let brightness = weights[0] * r + weights[1] * g + weights[2] * b;
          row.push(brightness);
        }
        brightnessMatrix.push(row);
      }

      // apply filter
      const blurRadius = parseInt(blurRadiusInput.value) || 0;
	  const sharpenRadius = parseInt(sharpenRadiusInput.value) || 0;
      const sobelRadius = parseInt(sobelRadiusInput.value) || 0;
	  let filteredMatrix = [];
      switch(document.querySelector('input[name="filterSelect"]:checked').value) {
	  // any of the filters return the original if the radius=0
	    case "blur":
		console.log("blur");
		  filteredMatrix = applyGaussianBlur(brightnessMatrix, blurRadius);
		  break;
		case "sharp":
		console.log("sharp");
	      filteredMatrix = applySharpen(brightnessMatrix, sharpenRadius);
		  break;
		case "edge":
		console.log("edge");
		  filteredMatrix = applySobel(brightnessMatrix, sobelRadius);
		  break;
		default:
		console.log("none");
		  filteredMatrix = brightnessMatrix;
      }

      // crop the matrix, gather min and max values in crop area
      const cropMatrix = [];
      let cropx1 = parseInt(cropID[edgeID[2]].value) || 0;
      let cropx2 = parseInt(cropID[edgeID[0]].value) || 0;
      let cropy1 = parseInt(cropID[edgeID[1]].value) || 0;
      let cropy2 = parseInt(cropID[edgeID[3]].value) || 0;
      let min = 32000;
      let max = -32000;
      for (let y=cropy1; y<uncropDim.height-cropy2; y++) {
        const row = [];
        for(let x=cropx1; x<uncropDim.width-cropx2; x++) {
          row.push(filteredMatrix[y][x]);
          min = Math.min(min, filteredMatrix[y][x]);
          max = Math.max(max, filteredMatrix[y][x]);
        }
        cropMatrix.push(row);
      }
      cropDim.width = uncropDim.width - cropx1 - cropx2;
      cropDim.height = uncropDim.height - cropy1 - cropy2;

      // normalize cropped image brightness to 0-255 range, invert brightness if checkbox is selected
      // adjust contrast if needed
      const range = max - min || 1;
      grayscaleMatrix = [];
      const grayImgData = grayscaleCtx.createImageData(cropDim.width, cropDim.height);
      const grayData = grayImgData.data;
      for (let y = 0; y < cropDim.height; y++) {
        const row = [];
        for (let x = 0; x < cropDim.width; x++) {
          let brightness = cropMatrix[y][x];
          brightness = ((brightness - min) / range) * 255;
          if (contrast>0.0002) // adjust contrast if contrast control > 0
            brightness = contrastAdj(brightness);
          if (invertBrightness)
            brightness = 255 - brightness;
          brightness = Math.max(0, Math.min(255, Math.round(brightness)));
          const i = (y * cropDim.width + x) * 4;
          grayData[i] = grayData[i+1] = grayData[i+2] = brightness;
          grayData[i + 3] = 255;
          row.push(brightness);
        }
        grayscaleMatrix.push(row);
      }

      // rotate and flip image
      const rotated = (rotation % 180 !== 0);
      const finalWidth = rotated ? cropDim.height : cropDim.width;
      const finalHeight = rotated ? cropDim.width : cropDim.height;
      grayscaleCanvas.width = finalWidth;
      grayscaleCanvas.height = finalHeight;

      const tempDrawCanvas = document.createElement('canvas');
      tempDrawCanvas.width = cropDim.width;
      tempDrawCanvas.height = cropDim.height;
      const tempDrawCtx = tempDrawCanvas.getContext('2d');
      tempDrawCtx.putImageData(grayImgData, 0, 0);

      grayscaleCtx.save();
      grayscaleCtx.setTransform(1, 0, 0, 1, 0, 0);
      grayscaleCtx.clearRect(0, 0, finalWidth, finalHeight);
      grayscaleCtx.translate(finalWidth / 2, finalHeight / 2);
      grayscaleCtx.rotate(rotation * Math.PI / 180);
      grayscaleCtx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
      grayscaleCtx.drawImage(tempDrawCanvas, -cropDim.width / 2, -cropDim.height / 2);
      grayscaleCtx.restore();

      grayscaleSizeText.textContent = `Output size: ${finalWidth}×${finalHeight}`;
      updateKbytes();
    }

    // image loading functions

    function resetInputs() { // executed after an image loads
      cropLeft.value="0";
      cropRight.value="0";
      cropTop.value="0";
      cropBottom.value="0";
      rotation = 0;
      flipV = flipH = false;
      resizeWidthInput.value = "100";
      blurRadiusInput.value = "0";
      sobelRadiusInput.value = "0";
      invertBrightnessCheckbox.checked = invertBrightness = false;
      contrastInput.value = contrastValue.textContent = "0";
      contrast = 0.0001;
      thresholdInput.value = thresholdValue.textContent = "0";
      threshold = 128.0/255.0;
    }

    // user pressed button to load image from disk
    imageInput.addEventListener('change', function () {
      const file = this.files[0];
      if (file && file.type.startsWith('image/')) {
        const reader = new FileReader();
        reader.onload = function (e) {
          currentImage.onload = function () {
            resetInputs();
            processImage();
          };
          currentImage.src = e.target.result;
        };
        reader.readAsDataURL(file);
      }
    });

    // user pasted an image from the clipboard into the input area
    inputArea.addEventListener('paste', function (event) {
      const items = (event.clipboardData || event.originalEvent.clipboardData).items;
      for (const item of items) {
        if (item.type.indexOf('image') !== -1) {
          const blob = item.getAsFile();
          const reader = new FileReader();
          reader.onload = function (e) {
            currentImage.onload = function () {
              resetInputs();
              processImage();
            };
            currentImage.src = e.target.result;
          };
          reader.readAsDataURL(blob);
        }
      }
    });

    // set up event listeners for all the input gadgets

    [blurRadiusInput, sobelRadiusInput, sharpenRadiusInput, contrastInput, thresholdInput,
        ...document.querySelectorAll('input[name="grayModel"]'),
        ...document.querySelectorAll('input[name="filterSelect"]')
		].forEach(el => el.addEventListener('input', processImage)
		);

    resizeWidthInput.addEventListener('input', function () {
        let min = parseInt(this.min);
        if (parseInt(this.value) < min) this.value = min;
        processImage();
    });

    cropLeft.addEventListener('input', () => {
        if (!currentImage.src) { cropLeft.value="0"; return; }
        const cl = parseInt(cropLeft.value) || 0;
        const cr = parseInt(cropRight.value) || 0;
        const newcl = uncropDim.width - cl - cr < 2 ? uncropDim.width - cr - 2 : cl;
        cropLeft.value = newcl.toString();
        resizeWidthInput.min = newcl + cr + 2;
        processImage();
    });
    cropTop.addEventListener('input', () => {
        if (!currentImage.src) { cropTop.value="0"; return; }
        const ct = parseInt(cropTop.value) || 0;
        const cb = parseInt(cropBottom.value) || 0;
        if(uncropDim.width - ct - cb < 2) cropTop.value = (uncropDim.height - cb - 2).toString();
        processImage();
    });
    cropRight.addEventListener('input', () => {
        if (!currentImage.src) { cropRight.value="0"; return; }
        const cl = parseInt(cropLeft.value) || 0;
        const cr = parseInt(cropRight.value) || 0;
        const newcr = uncropDim.width - cl - cr < 2 ? uncropDim.width - cl - 2 : cr;
        cropRight.value = newcr.toString();
        resizeWidthInput.min = cl + newcr + 2;
        processImage();
    });
    cropBottom.addEventListener('input', () => {
        if (!currentImage.src) { cropBottom.value="0"; return; }
        const ct = parseInt(cropTop.value) || 0;
        const cb = parseInt(cropBottom.value) || 0;
        if(uncropDim.width - ct - cb < 2) cropBottom.value = (uncropDim.height - ct - 2).toString();
        processImage();
    });

    function updateEdgeID(out="") {
        const fi = (flipH ? 1 : 0) + (flipV ? 2 : 0);
        const ri = Math.round(rotation/90);
        edgeID = edgeconfig[ri][fi];
        if (out.length>0) console.log(out, rotation, flipH, flipV, edgeID);
    }

    rotateLeftBtn.addEventListener('click', () => {
        if (!currentImage.src) return;
        rotation = (rotation - 90 + 360) % 360;
        const tmp = cropTop.value;
        cropTop.value = cropRight.value;
        cropRight.value = cropBottom.value;
        cropBottom.value = cropLeft.value;
        cropLeft.value = tmp;
        updateEdgeID();
        processImage();
    });
    rotateRightBtn.addEventListener('click', () => {
        if (!currentImage.src) return;
        rotation = (rotation + 90) % 360;
        const tmp = cropTop.value;
        cropTop.value = cropLeft.value;
        cropLeft.value = cropBottom.value;
        cropBottom.value = cropRight.value;
        cropRight.value = tmp;
        updateEdgeID();
        processImage();
    });
    flipHorizontalBtn.addEventListener('click', () => {
        if (!currentImage.src) return;
        flipH = !flipH;
        let tmp = cropRight.value;
        cropRight.value = cropLeft.value;
        cropLeft.value = tmp;
        updateEdgeID();
        processImage();
    });
    flipVerticalBtn.addEventListener('click', () => {
        if (!currentImage.src) return;
        flipV = !flipV;
        let tmp = cropTop.value;
        cropTop.value = cropBottom.value;
        cropBottom.value = tmp;
        updateEdgeID();
        processImage();
    });

    invertBrightnessCheckbox.addEventListener('input', () => {
       if (invertBrightness != invertBrightnessCheckbox.checked) {
         const t = Math.min(127, -parseInt(thresholdInput.value));
         threshold = (128.0+t)/255.0;
         thresholdInput.value = thresholdValue.textContent = t.toString();
       }
       invertBrightness = invertBrightnessCheckbox.checked;
       processImage();
    });

    contrastInput.addEventListener('input', function() {
        contrastValue.textContent = this.value;
        const c = parseFloat(this.value);
        contrast = c*c/100.0 + 0.0001;
        processImage();
    });
    thresholdInput.addEventListener('input', function() {
        thresholdValue.textContent = this.value;
        threshold = (parseFloat(this.value) + 128.0) / 255.0;
        processImage();
    });

    const Gbyte = 1073741824.0;
    const Mbyte = 1048576.0;
    const Kbyte = 1024.0;
    // update file size estimate based on normalize type and size of output image
    function updateKbytes() {
        // length of a number for [0,1] range: mostly 6 characters "0.xxx," but occasionally less, using 5.95.
        // length of a number for [0,255] range: assume 0-255 are uniformly distributed, use weighted average of digits plus comma
        const avglen = normalizeToUnitCheckbox.checked ? 5.95 : (10.0+90.0*2.0+156.0*3.0)/256.0+1.0;
        // each row has 6 extra characters " [],\r\n" at most, plus 5 characters after array name and 4 characters at the end
        const estsize = (avglen*cropDim.width + 6.0) * cropDim.height + 9 + arrayName.value.length;
        let unitName = "bytes";
        let unit = 1.0;
        if (estsize > Gbyte) { unit = Gbyte; unitName = "GiB"; }
        else if (estsize > Mbyte) { unit = Mbyte; unitName = "MiB"; }
        else if (estsize > 10.0*Kbyte) { unit = Kbyte; unitName = "KiB"; }
        const sizeOut = (estsize/unit).toFixed(unit==1.0?0:1);
        kbytes.textContent = `${sizeOut} ${unitName}`;
    }

    normalizeToUnitCheckbox.addEventListener('input', () => {
        updateKbytes();
    });

    // file output functions

    // try to use "Save As" file picker,
    // fall back to saving with a default name to browser's downloads directory.

    downloadButton.addEventListener('click', () => {
      if (grayscaleMatrix.length === 0) return alert("No data to save.");
      const useUnit = normalizeToUnitCheckbox.checked;
      const arrayContent = grayscaleMatrix.map(row => {
        return " [" + row.map(val => useUnit ? parseFloat((val/255.0).toFixed(3)) : val).join(",") + "]";
      }).join(",\n");
      const introcomment = " = [ // " + cropDim.width + "×" + cropDim.height + "\n";
	  const sizevar = (arrayName.value.length>0 ? arrayName.value : 'image_array')+"_size = [" + cropDim.width + "," + cropDim.height + "];\n";
      const openscadArray = sizevar + (arrayName.value.length>0 ? arrayName.value : 'image_array') + introcomment + arrayContent + "\n];";
      const blob = new Blob([openscadArray], { type: "text/plain" });
      const dimSuffix = "_"+cropDim.width + "x" + cropDim.height;
      let filename = (arrayName.value.length>0 ? arrayName.value : "image_array") + dimSuffix + '.scad';
      if (window.showSaveFilePicker) {
        saveWithFilePicker(blob, filename);
      } else {
        fallbackSave(blob, filename);
      }
    });

    async function saveWithFilePicker(blob, filename) {
      try {
        const handle = await window.showSaveFilePicker({
          suggestedName: filename,
          types: [{ description: 'OpenSCAD Data File', accept: { 'text/plain': ['.scad'] } }]
        });
        const writable = await handle.createWritable();
        await writable.write(blob);
        await writable.close();
      } catch (err) {
        alert('Save cancelled or failed: ' + err.message);
      }
    }

    function fallbackSave(blob, filename) {
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      a.target = "_blank";
      a.click();
      URL.revokeObjectURL(url);
    }
  </script>

</body>
</html>
